/*
 * MIT License
 *
 * Copyright (c) 2022 zycra
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in all
 * copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 * SOFTWARE.
 */

package com.gitee.zycra.jdbc.common;

import com.gitee.zycra.jdbc.annotation.BatchInsertIgnore;
import com.gitee.zycra.jdbc.annotation.Column;
import com.gitee.zycra.jdbc.annotation.ID;
import com.gitee.zycra.jdbc.annotation.Table;
import com.gitee.zycra.jdbc.annotation.TableScan;
import com.gitee.zycra.jdbc.model.DataObject;
import com.gitee.zycra.jdbc.model.MethodParamPair;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.support.BeanDefinitionRegistry;
import org.springframework.context.annotation.ImportBeanDefinitionRegistrar;
import org.springframework.core.annotation.AnnotationAttributes;
import org.springframework.core.io.Resource;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
import org.springframework.core.io.support.ResourcePatternResolver;
import org.springframework.core.type.AnnotationMetadata;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.util.Assert;

import java.lang.reflect.Field;
import java.net.URL;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;

/**
 * Data object resolution results container.
 *
 * @author zycra
 * @since 1.0.0
 */
public class DataObjectContainer implements ImportBeanDefinitionRegistrar {

    private static final Logger LOGGER = LoggerFactory.getLogger(DataObjectContainer.class);

    private static final String CLASSES_PATH = "classes";
    private static final String CLASS_SUFFIX = ".class";
    private static final String PACKAGE_SEPARATOR = ".";
    private static final String FILE_SEPARATOR = "/";
    private static final String FILE_PROTOCOL = "file";
    private static final String JAR_PROTOCOL = "jar";
    private static final String JAR_SEPARATOR_LABEL = "!/";
    private static final String GET_PREFIX = "get";
    private static final String SET_PREFIX = "set";
    private static final int JAR_SEPARATOR_LENGTH = 2;
    private static final int CLASSES_PATH_LENGTH = 9;

    /**
     * Data object resolution results map, the key is class name and the value is resolution results.
     *
     * @since 1.0.0
     */
    private static final Map<String, DataObject> DATA_OBJECT_MAP = new HashMap<>();

    /**
     * Scan the {@link TableScan} declared packages and resolve the {@link Table} marked class.
     *
     * @param importingClassMetadata annotation meta data.
     * @param registry               bean definition registry.
     * @since 1.0.0
     */
    @Override
    public void registerBeanDefinitions(AnnotationMetadata importingClassMetadata, BeanDefinitionRegistry registry) {
        AnnotationAttributes tableScanAttrs = AnnotationAttributes.fromMap(importingClassMetadata.getAnnotationAttributes(TableScan.class.getName()));
        if (tableScanAttrs == null) {
            return;
        }
        try {
            String[] basePackages = tableScanAttrs.getStringArray("value");
            ClassLoader classLoader = Thread.currentThread().getContextClassLoader();
            ResourcePatternResolver resourcePatternResolver = new PathMatchingResourcePatternResolver();
            for (String basePackage : basePackages) {
                Resource[] resources = resourcePatternResolver
                        .getResources(ResourcePatternResolver.CLASSPATH_ALL_URL_PREFIX + basePackage.replace(PACKAGE_SEPARATOR, FILE_SEPARATOR));
                for (Resource resource : resources) {
                    Class<?> clazz = classLoader.loadClass(getClassName(resource.getURL()));
                    if (clazz.isAnnotationPresent(Table.class)) {
                        addClassToContainer(clazz);
                    }
                }
            }
        } catch (Exception e) {
            LOGGER.error("init scan table error", e);
        }
    }

    /**
     * Return class name of given url.
     *
     * @param url the class url.
     * @return class name of given url.
     * @since 1.0.0
     */
    private String getClassName(URL url) {
        String protocol = url.getProtocol();
        String path = url.getPath();
        if (FILE_PROTOCOL.equals(protocol)) {
            return path.substring(path.indexOf(FILE_SEPARATOR + CLASSES_PATH + FILE_SEPARATOR) + CLASSES_PATH_LENGTH)
                    .replace(FILE_SEPARATOR, PACKAGE_SEPARATOR).replace(CLASS_SUFFIX, "");
        }
        if (JAR_PROTOCOL.equals(protocol)) {
            return path.substring(path.lastIndexOf(JAR_SEPARATOR_LABEL) + JAR_SEPARATOR_LENGTH)
                    .replace(FILE_SEPARATOR, PACKAGE_SEPARATOR).replace(CLASS_SUFFIX, "");
        }
        return null;
    }

    /**
     * Resolve {@link Table} marked class and add result to data object container.
     *
     * @param clazz {@link Table} marked class.
     * @since 1.0.0
     */
    private void addClassToContainer(Class<?> clazz) {
        String className = clazz.getName();
        Table table = clazz.getAnnotation(Table.class);
        Field[] fields = clazz.getDeclaredFields();
        Map<String, String> getColumnMethodMap = new HashMap<>();
        Map<String, MethodParamPair> setColumnMethodMap = new HashMap<>();
        Set<String> batchInsertSet = new HashSet<>();
        String idColumnName = null;
        String getIdColumnMethod = null;
        Class<?> idParamType = null;
        for (Field field : fields) {
            String fieldName = field.getName();
            String upperFieldName = fieldName.substring(0, 1).toUpperCase() + fieldName.substring(1);
            String columnName = field.isAnnotationPresent(Column.class) ? field.getAnnotation(Column.class).value() : fieldName;
            String getMethodName = GET_PREFIX + upperFieldName;
            String setMethodName = SET_PREFIX + upperFieldName;
            Class<?> fieldType = field.getType();
            getColumnMethodMap.put(columnName, getMethodName);
            setColumnMethodMap.put(columnName, new MethodParamPair(setMethodName, fieldType));
            if (field.isAnnotationPresent(ID.class)) {
                Assert.isNull(idColumnName, "duplicate id column declaration, className=" + className);
                idColumnName = columnName;
                getIdColumnMethod = getMethodName;
                idParamType = fieldType;
            }
            if (!field.isAnnotationPresent(BatchInsertIgnore.class)) {
                batchInsertSet.add(columnName);
            }
        }
        Assert.notNull(idColumnName, "no id column declaration, className=" + className);
        DataObject dataObject = new DataObject(table.value(), idColumnName, getIdColumnMethod, idParamType, getColumnMethodMap, setColumnMethodMap,
                batchInsertSet);
        DATA_OBJECT_MAP.put(className, dataObject);
    }

    /**
     * Return defined table name by data object class.
     *
     * @param clazz data object class.
     * @param <T>   data object class type.
     * @return defined table name.
     * @since 1.0.0
     */
    static <T> String getTable(Class<T> clazz) {
        DataObject dataObject = DATA_OBJECT_MAP.get(clazz.getName());
        return dataObject == null ? null : dataObject.getTableName();
    }

    /**
     * Return defined primary key field name by data object class.
     *
     * @param clazz data object class.
     * @param <T>   data object class type.
     * @return defined primary key field name.
     * @since 1.0.0
     */
    static <T> String getIdColumnName(Class<T> clazz) {
        DataObject dataObject = DATA_OBJECT_MAP.get(clazz.getName());
        return dataObject == null ? null : dataObject.getIdColumnName();
    }

    /**
     * Return defined primary key value by data object instance.
     *
     * @param model data object instance.
     * @param <T>   data object instance type.
     * @return defined primary key value.
     * @since 1.0.0
     */
    static <T> Object getIdColumnValue(T model) {
        DataObject dataObject = DATA_OBJECT_MAP.get(model.getClass().getName());
        if (dataObject == null) {
            return null;
        }
        try {
            return model.getClass().getMethod(dataObject.getGetIdColumnMethod()).invoke(model);
        } catch (Exception e) {
            LOGGER.error("invoke get method error", e);
        }
        return null;
    }

    /**
     * Return batch insert field names by data object class.
     *
     * @param clazz data object class.
     * @param <T>   data object class type.
     * @return defined batch insert field names.
     * @since 1.0.0
     */
    static <T> Set<String> getBatchInsertColumnSet(Class<T> clazz) {
        DataObject dataObject = DATA_OBJECT_MAP.get(clazz.getName());
        if (dataObject == null) {
            return Collections.emptySet();
        }
        return dataObject.getBatchInsertSet();
    }

    /**
     * Return all column names by data object class.
     *
     * @param clazz data object class.
     * @param <T>   data object class type.
     * @return defined all column names.
     * @since 1.0.0
     */
    static <T> Set<String> getAllColumnSet(Class<T> clazz) {
        DataObject dataObject = DATA_OBJECT_MAP.get(clazz.getName());
        if (dataObject == null) {
            return Collections.emptySet();
        }
        return dataObject.getGetColumnMethodMap().keySet();
    }

    /**
     * Return all column name and value map by data object instance.
     *
     * @param model data object instance.
     * @param <T>   data object instance type.
     * @return defined all column name and value map.
     * @since 1.0.0
     */
    static <T> Map<String, Object> getAllColumnMap(T model) {
        return getColumnMap(model, true);
    }

    /**
     * Return not null column name and value map by data object instance.
     *
     * @param model data object instance.
     * @param <T>   data object instance type.
     * @return not null column name and value map.
     * @since 1.0.0
     */
    static <T> Map<String, Object> getNotNullColumnMap(T model) {
        return getColumnMap(model, false);
    }

    /**
     * Return column name and value map by data object instance.
     *
     * @param model data object instance.
     * @param all   true is all column map, false is not null column map.
     * @param <T>   data object instance type.
     * @return column name and value map.
     * @since 1.0.0
     */
    private static <T> Map<String, Object> getColumnMap(T model, boolean all) {
        Class<?> clazz = model.getClass();
        DataObject dataObject = DATA_OBJECT_MAP.get(clazz.getName());
        if (dataObject == null) {
            return Collections.emptyMap();
        }
        Map<String, Object> result = new HashMap<>();
        Map<String, String> getColumnMethodMap = dataObject.getGetColumnMethodMap();
        try {
            for (Map.Entry<String, String> entry : getColumnMethodMap.entrySet()) {
                Object value = clazz.getMethod(entry.getValue()).invoke(model);
                if (all || value != null) {
                    result.put(entry.getKey(), value);
                }
            }
            return result;
        } catch (Exception e) {
            LOGGER.error("invoke get method error", e);
        }
        return Collections.emptyMap();
    }

    /**
     * Return row mapper for resolve SQL result set by data object class.
     *
     * @param clazz data object class.
     * @param <T>   data object class type.
     * @return row mapper for resolve SQL result set.
     * @since 1.0.0
     */
    static <T> RowMapper<T> getRowMapper(Class<T> clazz) {
        if (!DATA_OBJECT_MAP.containsKey(clazz.getName())) {
            return null;
        }
        return (resultSet, i) -> {
            try {
                T t = clazz.getDeclaredConstructor().newInstance();
                Map<String, MethodParamPair> setColumnMethodMap = DATA_OBJECT_MAP.get(clazz.getName()).getSetColumnMethodMap();
                for (Map.Entry<String, MethodParamPair> entry : setColumnMethodMap.entrySet()) {
                    MethodParamPair methodParamPair = entry.getValue();
                    Object value = resultSet.getObject(entry.getKey(), methodParamPair.getParamClass());
                    clazz.getMethod(methodParamPair.getMethodName(), methodParamPair.getParamClass()).invoke(t, value);
                }
                return t;
            } catch (Exception e) {
                LOGGER.error("create instance error", e);
            }
            return null;
        };
    }
}
